const assert = require('assert');
const frontMatter = require('front-matter');
const fs = require('fs');
const handlebars = require('handlebars');
const marked = require('marked');
const path = require('path');
const pkg = require('../../package.json');
const promisify = require('util').promisify;
const RawSource = require('webpack-sources').RawSource;

const readFile = promisify(fs.readFile);
const isCssRegEx = /\.css$/;
const isJsRegEx = /\.js(\?.*)?$/;
const importRegEx = /^import .* from '(.*)';$/;

handlebars.registerHelper(
  'md',
  (str) => new handlebars.SafeString(marked(str))
);

handlebars.registerHelper('indent', (text, options) => {
  if (!text) {
    return text;
  }
  const count = options.hash.spaces || 2;
  const spaces = new Array(count + 1).join(' ');
  return text
    .split('\n')
    .map((line) => (line ? spaces + line : ''))
    .join('\n');
});

/**
 * Create an inverted index of keywords from examples.  Property names are
 * lowercased words.  Property values are objects mapping example index to word
 * count.
 * @param {Array<Object>} exampleData Array of example data objects.
 * @return {Object} Word index.
 */
function createWordIndex(exampleData) {
  const index = {};
  const keys = ['shortdesc', 'title', 'tags'];
  exampleData.forEach((data, i) => {
    keys.forEach((key) => {
      let text = data[key];
      if (Array.isArray(text)) {
        text = text.join(' ');
      }
      const words = text ? text.split(/\W+/) : [];
      words.forEach((word) => {
        if (word) {
          word = word.toLowerCase();
          let counts = index[word];
          if (counts) {
            if (index in counts) {
              counts[i] += 1;
            } else {
              counts[i] = 1;
            }
          } else {
            counts = {};
            counts[i] = 1;
            index[word] = counts;
          }
        }
      });
    });
  });
  return index;
}

/**
 * Gets the source for the chunk that matches the jsPath
 * @param {Object} chunk Chunk.
 * @param {string} jsName Name of the file.
 * @return {string} The source.
 */
function getJsSource(chunk, jsName) {
  let jsSource;
  for (let i = 0, ii = chunk.modules.length; i < ii; ++i) {
    const module = chunk.modules[i];
    if (module.modules) {
      jsSource = getJsSource(module, jsName);
      if (jsSource) {
        return jsSource;
      }
    }
    if (module.identifier.endsWith(jsName) && module.source) {
      return module.source;
    }
  }
}

/**
 * Gets dependencies from the js source.
 * @param {string} jsSource Source.
 * @return {Object<string, string>} dependencies
 */
function getDependencies(jsSource) {
  const lines = jsSource.split('\n');
  const dependencies = {
    ol: pkg.version,
  };
  for (let i = 0, ii = lines.length; i < ii; ++i) {
    const line = lines[i];
    const importMatch = line.match(importRegEx);
    if (importMatch) {
      const imp = importMatch[1];
      if (!imp.startsWith('ol/') && imp != 'ol') {
        const parts = imp.split('/');
        let dep;
        if (imp.startsWith('@')) {
          dep = parts.slice(0, 2).join('/');
        } else {
          dep = parts[0];
        }
        if (dep in pkg.devDependencies) {
          dependencies[dep] = pkg.devDependencies[dep];
        }
      }
    }
  }
  return dependencies;
}

class ExampleBuilder {
  /**
   * A webpack plugin that builds the html files for our examples.
   * @param {Object} config Plugin configuration.  Requires a `templates` property
   * with the path to templates and a `common` property with the name of the
   * common chunk.
   */
  constructor(config) {
    this.templates = config.templates;
    this.common = config.common;
  }

  /**
   * Called by webpack.
   * @param {Object} compiler The webpack compiler.
   */
  apply(compiler) {
    compiler.hooks.emit.tapPromise('ExampleBuilder', async (compilation) => {
      const chunks = compilation
        .getStats()
        .toJson()
        .chunks.filter((chunk) => chunk.names[0] !== this.common);

      const exampleData = [];
      const uniqueTags = new Set();
      const promises = chunks.map(async (chunk) => {
        const [assets, data] = await this.render(compiler.context, chunk);

        // collect tags for main page... TODO: implement index tag links
        data.tags.forEach((tag) => uniqueTags.add(tag));

        exampleData.push({
          link: data.filename,
          example: data.filename,
          title: data.title,
          shortdesc: data.shortdesc,
          tags: data.tags,
        });

        for (const file in assets) {
          compilation.assets[file] = new RawSource(assets[file]);
        }
      });

      await Promise.all(promises);

      const info = {
        examples: exampleData,
        index: createWordIndex(exampleData),
        tags: Array.from(uniqueTags),
      };

      const indexSource = `var info = ${JSON.stringify(info)}`;
      compilation.assets['index.js'] = new RawSource(indexSource);
    });
  }

  async render(dir, chunk) {
    const name = chunk.names[0];

    const assets = {};
    const readOptions = {encoding: 'utf8'};

    const htmlName = `${name}.html`;
    const htmlPath = path.join(dir, htmlName);
    const htmlSource = await readFile(htmlPath, readOptions);

    const {attributes, body} = frontMatter(htmlSource);
    const data = Object.assign(attributes, {contents: body});

    data.olVersion = pkg.version;
    data.filename = htmlName;

    // process tags
    if (data.tags) {
      data.tags = data.tags.replace(/[\s"]+/g, '').split(',');
    } else {
      data.tags = [];
    }

    // add in script tag
    const jsName = `${name}.js`;
    let jsSource = getJsSource(chunk, path.join('.', jsName));
    if (!jsSource) {
      throw new Error(`No .js source for ${jsName}`);
    }
    // remove "../src/" prefix and ".js" to have the same import syntax as the documentation
    jsSource = jsSource.replace(/'\.\.\/src\//g, "'");
    jsSource = jsSource.replace(/\.js';/g, "';");
    if (data.cloak) {
      for (const entry of data.cloak) {
        jsSource = jsSource.replace(new RegExp(entry.key, 'g'), entry.value);
      }
    }
    // Remove worker loader import and modify `new Worker()` to add source
    jsSource = jsSource.replace(
      /import Worker from 'worker-loader![^\n]*\n/g,
      ''
    );
    jsSource = jsSource.replace('new Worker()', "new Worker('./worker.js')");

    data.js = {
      tag: `<script src="${this.common}.js"></script><script src="${jsName}"></script>`,
      source: jsSource,
    };

    if (data.experimental) {
      const prelude = '<script>window.experimental = true;</script>';
      data.js.tag = prelude + data.js.tag;
    }

    // check for worker js
    const workerName = `${name}.worker.js`;
    const workerPath = path.join(dir, workerName);
    let workerSource;
    try {
      workerSource = await readFile(workerPath, readOptions);
    } catch (err) {
      // pass
    }
    if (workerSource) {
      // remove "../src/" prefix and ".js" to have the same import syntax as the documentation
      workerSource = workerSource.replace(/'\.\.\/src\//g, "'");
      workerSource = workerSource.replace(/\.js';/g, "';");
      if (data.cloak) {
        for (const entry of data.cloak) {
          workerSource = workerSource.replace(
            new RegExp(entry.key, 'g'),
            entry.value
          );
        }
      }
      data.worker = {
        source: workerSource,
      };
      assets[workerName] = workerSource;
    }

    data.pkgJson = JSON.stringify(
      {
        name: name,
        dependencies: getDependencies(
          jsSource + (workerSource ? `\n${workerSource}` : '')
        ),
        devDependencies: {
          parcel: '1.11.0',
        },
        scripts: {
          start: 'parcel index.html',
          build:
            'parcel build --experimental-scope-hoisting --public-url . index.html',
        },
      },
      null,
      2
    );

    // check for example css
    const cssName = `${name}.css`;
    const cssPath = path.join(dir, cssName);
    let cssSource;
    try {
      cssSource = await readFile(cssPath, readOptions);
    } catch (err) {
      // pass
    }
    if (cssSource) {
      data.css = {
        tag: `<link rel="stylesheet" href="${cssName}">`,
        source: cssSource,
      };
      assets[cssName] = cssSource;
    }

    // add additional resources
    if (data.resources) {
      const resources = [];
      const remoteResources = [];
      const codePenResources = [];
      for (let i = 0, ii = data.resources.length; i < ii; ++i) {
        const resource = data.resources[i];
        const remoteResource =
          resource.indexOf('//') === -1
            ? `https://openlayers.org/en/v${pkg.version}/examples/${resource}`
            : resource;
        codePenResources[i] = remoteResource;
        if (isJsRegEx.test(resource)) {
          resources[i] = `<script src="${resource}"></script>`;
          remoteResources[i] = `<script src="${remoteResource}"></script>`;
        } else if (isCssRegEx.test(resource)) {
          if (resource.indexOf('bootstrap.min.css') === -1) {
            resources[i] = '<link rel="stylesheet" href="' + resource + '">';
          }
          remoteResources[i] =
            '<link rel="stylesheet" href="' + remoteResource + '">';
        } else {
          throw new Error(
            'Invalid value for resource: ' +
              resource +
              ' is not .js or .css: ' +
              htmlName
          );
        }
      }
      data.extraHead = {
        local: resources.join('\n'),
        remote: remoteResources.join('\n'),
      };
      data.extraResources = data.resources.length
        ? ',' + codePenResources.join(',')
        : '';
    }

    assert(!!attributes.layout, `missing layout in ${htmlPath}`);
    const templatePath = path.join(this.templates, attributes.layout);
    const templateSource = await readFile(templatePath, readOptions);

    assets[htmlName] = handlebars.compile(templateSource)(data);
    return [assets, data];
  }
}

module.exports = ExampleBuilder;
